Add browser builds with persistence, WebGL2, and YOG cross-play - #203
Add browser builds with persistence, WebGL2, and YOG cross-play#203genixpro wants to merge 20 commits into
Conversation
36d3687 to
94cdfbe
Compare
|
This is genuinely exciting. A browser build is the single biggest thing we could do for Some things I want to be explicit about before this lands, mostly because the cost of Generated map layouts change. ADR 005 states this outright. I accept the reasoning: Two screen-execution models now coexist. ADR 003 is honest that C++20 coroutines in the loader. Networking was rewritten. Small thing: three surviving files lose their What reassures me most is the shape of your evidence, not its volume: freezing the old One expectation to set: the description defers production hosting and operations, and the Thank you for the ADRs and for the candour in |
|
So what's the goal of this? Instant custom games and campaigns (single player) from our website or YOG and LAN, too? The former would already be awesome and could be its own branch if the consequences for merging this to master were somehow too controversial. |
495a657 to
3dcbde1
Compare
5dd4fe9 to
bd49479
Compare
|
Thanks — the intended scope is now clearer after cleaning the branch up. This PR makes custom games, campaigns, saves/imports, and the existing YOG flow work in the browser. YOG goes through the WebSocket gateway and supports browser/native cross-play. LAN is compiled out of the WebAssembly UI: browser sandboxing does not provide the direct TCP/listen/discovery model that Glob2 LAN uses. For generated maps, YOG clients do not independently regenerate the selected map. The host supplies the Longer term, I would like the browser release to grow a web-native multiplayer entry flow on top of this cross-play foundation: shareable match links, guest or lightweight identity, and instant matchmaking without requiring account creation first. That product flow is follow-up work rather than part of this implementation cleanup. I also restored the three copyright lines you identified and added API documentation to the new |
cfd2e9d to
7962c30
Compare
Giszmo
left a comment
There was a problem hiding this comment.
Requesting changes, but I want to lead with what this review is not about.
I was worried about the +16,330 / −4,203 and about what a browser port would cost the desktop
game. I built both sides and measured rather than guessed, and on those two questions the PR
holds up: the simulation is bit-identical and the engine gets slightly simpler. The four
changes below are dependency and packaging issues, not architecture.
Requested changes
1. OpenSSL is now a hard requirement for every desktop build — please put it behind a flag
SConstruct:166 appends to missing and fails the build if OpenSSL is absent, for any
server=0 configuration. There is no flag and no fallback, so every packager on every
platform now needs OpenSSL to build a client that may never open a wss:// connection.
The size cost is concentrated in one file. Attributing the binary growth per object on the
link line:
src/net/WssTransport.o 828,479 bytes of .text
all 362 other shared .o +162,873 bytes total (+5.5%)
WssTransport.cpp is 168 lines, but it includes boost/asio.hpp, boost/asio/ssl.hpp and
four boost/beast headers. The linked binary goes 5,486,152 → 7,656,528 bytes (+40%, .text
+1.50 MB) and picks up libssl.so.3 and libcrypto.so.3.
I am not asking you to drop it — NativeTransport::open (src/net/NetTransport.cpp:106)
selects WssTransport for a wss:// address, so this is reachable code that desktop
cross-play needs. I am asking for a build option (default on is fine) so a distro or a
minimal build can compile the client without OpenSSL and Boost.Beast, with
NativeTransport falling back to TCP-only.
2. debian/control was not updated
Build-Depends still has no libssl-dev, and nothing under darwin/ changed. The CI jobs
install libssl-dev and mingw-w64-x86_64-openssl explicitly, so CI stays green and the
first thing that breaks is a package build. Please add it (and whatever the macOS bundle
needs) in this PR.
3. Explain the regenerated determinism baselines
games/cross-replay.game is unchanged, but both baselines were replaced:
tests/baselines/cross-replay.replay: 256,668 → 610,606 bytes, diverging at byte 24tests/baselines/cross-replay.checksums: same size, identical for the first 4,668 bytes,
then 9,209 differing bytes
That reads alarming, so I checked it before writing this: the engine is bit-identical on that
exact save (evidence below), so this is a new, longer recording rather than a behaviour
change. But no in-repo test compares against cross-replay.checksums — the only in-repo
consumers of tests/baselines/ are two Playwright specs that use the .replay as an import
fixture. The oracle these files serve lives in the sibling cross-replay tooling, so replacing
them silently rebases something outside this repo.
Please say in the PR description why they were re-recorded and confirm the sibling tooling
was regenerated to match. If they did not need to change, revert them.
4. Hoist the per-tile work out of the FertilityCalculator kernel loop
Job::advance (src/FertilityCalculator.cpp:82) spends its operations budget per kernel
cell, and kKernelSide is 31 — so 961 iterations per tile. Each of those calls
State::coordinate() (:72), which is cursor / map.getH() and cursor % map.getH().
Map::getH() returns a runtime member (src/map/Map.h:155), so that is real integer division
961× per tile where the old nested loop did it once, plus a coordToIndex and distance[]
lookup that used to be hoisted out of the kernel loop.
I did not benchmark this, so treat it as a code reading. The scope is narrow — only maps older
than FILE_FORMAT_VERSION_PRE_FERTILITY reach it, at load time (src/Game_io.cpp:306) — but
computing x, y and the grass/reachability test once per tile instead of 961 times is a
small change and keeps the incremental version honest against the one it replaces.
Evidence for the parts I am not asking you to change
Merge base 84a9b8b6 vs head 7962c30e, both built scons -j4 release=1 server=0, identical
flags (-O3 -std=gnu++20), native Linux.
Simulation is bit-identical. Headless --nox, SDL_VIDEODRIVER=dummy:
| run | master | this PR | final checksum |
|---|---|---|---|
20,000 ticks, cross-replay.game (SmallForTwo, 2 AI) |
1680 / 1650 / 1656 ms | 1672 / 1664 / 1668 ms | c44b9dfa both |
20,000 ticks, gd-large-4ai.game |
6643 / 6558 ms | 6599 / 6632 ms | 3d4ce420 both |
1,500 ticks with GLOB2_CHECKSUM_SIDECAR=1 |
— | — | 3,660,536 bytes of per-tick, per-unit and per-building checksums, cmp-identical |
Save files cross-load in both directions: a save written by this branch loads on master and
yields the same checkSum() = 302ef289 after 500 ticks.
The line count is not engine code. By destination: browser/ +3,251, data/ +2,614, tests
+2,593, docs and ADRs +1,153, build system +840, deploy and CI +385. Shared engine code moves
+5,494 / −4,070 across 225 files, net +1,424. src/ + libgag/ total goes 134,145 → 136,076
lines, +1.4%. Unit.cpp, Map.cpp and Building.cpp are untouched; Game.cpp is −1 line.
There are 11 __EMSCRIPTEN__ sites in shared code across 6 files, and browser/ ships ~267
lines of JavaScript against 2,031 lines of Playwright tests.
Two things I expected to be debt and are not. src/net excluding the test suite goes
2,507 → 2,088 lines, and that 2,088 already includes the new WssTransport.cpp and
gateway/Gateway.cpp; native-only networking is about a third smaller, and
add_net_thread_message.py — a 103-line code generator — is gone. And the screen migration is
not the half-finished state I assumed from ADR 003: Screen::execute is now implemented on top
of the nonblocking primitives rather than duplicating them, and direct blocking execute( /
runModal() call sites drop from 44 to one (src/Engine.cpp:95, the end screen).
scons release=1 server=0 screen-test session-test builds clean here, and
ScreenExecutionHarness plus test/run-engine-session-test.py give 12 PASS and exit 0.
Non-blocking notes
libgag/src/GraphicContextCompound.cpp:85adds
if (!surface->textureInfo) glState.setTexture(surface->texture);, but theelsebranch
eight lines down binds the same texture, andGLState::setTexture
(libgag/src/GraphicContext.cpp:80) early-returns when unchanged. Harmless, just redundant
on the native path.CooperativeTask::promise_typeallocates ashared_ptr<State>inget_return_object()that
Awaiter::await_suspendimmediately overwrites with the parent's for every child task. One
wasted allocation per subtask.- The cmd+Q / alt+F4 handling moved from
SDL_GetModState()toevent.key.keysym.mod. That
reads intentional — synthetic and replayed events need the event's own modifier state — but
it is a macOS/Windows behaviour change that no CI job covers. - The new
webjob (Emscripten,npm ci, Playwright across chromium + firefox + webkit,
pulseaudio, 60-minute timeout) is a real recurring CI cost and a new flake surface. Worth
deciding on deliberately rather than inheriting.
Thank you for the ADRs and for status.md. Being able to read your reasoning for screen
execution, cooperative loading and generation randomness before reading the diff is what made a
349-file PR reviewable at all.
Opus 5 helped authoring this review.
7962c30 to
81f91fb
Compare
|
Addressed the requested changes in the rebased five-commit series:
CI is running on |
|
On item 3 (the regenerated determinism baselines):
The fixture predates all of that, so it fails the floor check outright once rebased past any of those merges. That's not cosmetic: Separately: this branch is still pinned at On the sibling cross-replay tooling: that's @kylelutze's Rust reader, not something in this repo, so I can't regenerate or verify its oracle myself. Kyle — could you either grant visibility into it so this can be checked directly in future PRs, or regenerate/confirm its side against the baseline once this branch lands on the new bytes? Happy to ping you again once the next rebase (to version 94) is up. I don't think this last piece should gate the merge decision here — the in-repo tests that actually load this fixture are covered, and the Rust-side sync is tracked as a follow-up rather than something this PR can resolve on its own — but I wanted it on record rather than left implicit. |
Introduce the browser shell and pinned Emscripten toolchain, isolate native and web build outputs, and add the bounded WebSocket gateway. Keep the native build usable alongside browser artifacts.
Drive native and browser entry points through the shared application host and owned screen stack. Convert loading, editing, map generation, and related UI flows to cooperative work that can advance across browser frames.
Connect the existing framed YOG protocol through browser WebSockets, retain native TCP and WSS peers, schedule multiplayer navigation safely, and add the tested gateway deployment configuration and cross-play coverage.
Integrate browser-specific services with the shared source layout, make persistence failures recoverable, support validated file import and export, resize active screens at frame boundaries, and add opt-in WebGL2 context recovery.
Finish settings, audio, navigation, replay-save, and multiplayer edge cases; remove Asyncify; expand native and cross-browser regression coverage; refresh reviewer documentation; and omit machine-specific results, screenshots, and handoff logs from the PR.
The linux and windows jobs still pointed run-savegame-safety-tests.py at build/src/TrappedUnitLifecycleTest[.exe], the pre-build_layout.py path. Every sibling harness step in the same jobs already moved to the per-toolchain/role/mode layout; these two were missed, so both jobs failed immediately with FileNotFoundError. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV
92439b6 to
ecc35f7
Compare
master's tip (#223, fetch-job apportionment) bumped VERSION_MINOR and REPLAY_MINIMUM_VERSION_MINOR to 94 after this branch's last recording at 93. ReplayReader::open() rejects anything below the floor, and browser/tests/import.spec.js and session-reload.spec.js both import this exact file and expect it to load. Re-recorded from the unchanged games/cross-replay.game (seed 42, SmallForTwo, Econo vs Nicowar) run to its natural end via `--nox games/cross-replay.game 0 1`, with GLOB2_CHECKSUM_SIDECAR_MAX_TICKS=100 to match the existing checksum sidecar's cap. cross-replay.checksums came out byte-identical to the prior recording — expected, since the sidecar only covers the first 100 ticks and Giszmo's review already established the simulation is bit-identical between master and this branch on this exact save. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV
|
Rebased onto master's current tip ( Verified locally: native client and server both build clean ( On item 3: CI is running on |
39 keys added for import/export and durable-save-failure UI (Loading headers, saving to storage, storage restore failed, etc.) had only their English source text copied into every non-English catalog. master's newer test_catalogs_do_not_reintroduce_english_fallbacks (pulled in by the rebase) flags any non-English catalog whose value for a key equals the English source and isn't reviewed shared vocabulary — this predates the rebase but was never checked before, since that test didn't exist on this branch until now. Translated all 39 keys into all 32 non-English catalogs. Two catalog codes don't follow ISO 639-1: texts.si.txt holds Slovenian, not Sinhala (the shipped fonts have no Sinhala glyphs at all, confirmed by test_font_coverage.py, and none of its existing content is Sinhala either); texts.sr.txt is Serbian in Cyrillic, matching its existing content, not Latin. Verified: check_translations.py --strict (0 structural errors), test_translations.py, test_font_coverage.py, and test_text_area_layout.py all pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV
|
@Giszmo — this should be ready for another look now. Summary of everything since your review: Your 4 requested changes:
Also since your review, the branch turned out to be 5 master commits behind (not the 1 I first assumed), including the Settings (#236) and custom-game lobby (#237) redesigns landing around the same time — full rebase onto master's current tip is up now, with Native client and server both build clean locally, headless run is deterministic, and all four translation checks pass. CI is running now on |
Giszmo
left a comment
There was a problem hiding this comment.
My judgement is colored by wanting this to be a thing on globulation2.org so I don't give approval but I hope @stephanemagnenat does ;)
CI caught what local scons/server builds didn't: none of the explicit harness targets (custom-setup-test, session-test, etc.) build under a bare `scons`, so the earlier rebase verification never compiled them. - test/CustomGameSetupHarness.cpp: fix six ScreenStack constructions left argument-less by the earlier mechanical patch (ScreenStack has no default constructor); rewrite the driver-based UI test to push CustomGameScreen onto a real ScreenStack and drive it via ScreenStack::execute(), matching SinglePlayerFlow::custom(), since Engine::initCustom(void) no longer exists. - Match speed selection was lost in the CustomGameScreen merge: master's redesigned lobby has CustomGameScreen::selectedSpeed(), but neither SinglePlayerFlow::custom() nor the old Engine::initCustom(void) it replaced actually applied it. Added an optional speed parameter to Engine::initCustomTask() (sets/restores previousCustomSpeed, mirroring the removed method) and wired it through SinglePlayerFlow and the test. - test/EngineSessionHarness.cpp used SettingsScreen::OK, an enum from the pre-#236 widget-based screen this branch never knew about; master's redesign has no such enum. Replaced with a direct done() call. - SettingsScreen::done() closed synchronously once local writes succeeded, never actually waiting on the browser persistence flush added during the rebase reconciliation — contradicted this exact session-test assertion ("Settings must poll persistence before closing"). done() now always confirms durability via persist() and only calls endExecute() once any pending flush resolves; onTimer() drives that completion (or reopens editing on a flush failure). Verified: all 24 harness targets CI builds compile clean locally, and engine-session-test (including "settings close only after persistence completion") and custom-setup-test both pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV
CustomGameSetupHarness (4 invocations) and BuildingExpelHarness were still on the pre-build_layout.py path, missed by the earlier trapped-unit-test fix and the merge that introduced these two steps. Audited every remaining harness path in the workflow against the build_layout.py <toolchain>/<role>/<mode> scheme; no others were stale. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV
…irty
CI's browser job failed 28 Playwright specs, almost all with the same
symptom: the very first "open Settings, close it untouched" in a fresh
profile never returns to MainMenuScreen (30s timeout). The one directly
relevant spec that passed ("settings wait for durable storage before
closing") deliberately stalls the storage write before ever attempting
a real one — every spec that lets a real, unstalled first close happen
hangs, including specs about unrelated screens/flows that just happen
to touch Settings once during setup.
Master's persist() only calls settings.save()/saveKeyboardLayout() when
the corresponding dirty flag is set. The original (pre-redesign)
savePreferences() this replaced always called them unconditionally.
Every other caller of persist() (commit(), finishInteraction(), the
onTimer retry path) already has at least one dirty flag set by the time
it calls persist(), so gating never mattered there — done() calling
persist() on a completely untouched screen was the one path this
skipped real writes on, and it's exactly the failing scenario. Also
restored the try/catch the original had around this whole sequence,
which persist() had dropped.
I can't run the actual browser/Playwright suite from here to confirm
this is the full fix rather than a partial one; pushing it to let CI
verify.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV
The 28 failing browser Playwright tests all shared one symptom (Settings never returned to MainMenuScreen), which I first misdiagnosed as a persist() bug. Running the actual Emscripten+Playwright toolchain locally (rather than guessing from CI logs) showed persistStorage() was never even being invoked: the tests click stale pixel coordinates left over from before master's Settings redesign (#236). A screenshot of the real 1200x900 layout put "Done" at (1015,777), nowhere near the tests' (600,650). Root-caused via direct instrumentation (Module.persistenceResults, Module.storage.state) and confirmed by clicking the real coordinate. While fixing the coordinates I found a genuine behavioral gap the redesign introduced: the old screen had a Cancel/Continue button that always closed in one click, independent of the save outcome; the new footer only had "Done" (gated on success) and no equivalent. Added it back as SettingsScreen::abandon(), wired to an always-visible footer button that relabels itself "Continue" once a save has actually failed, reusing the existing "[settings continue]"/"[settings Cancel]" translated strings. Verified empirically: Done still blocks while failed and retries on every click; the new button never retries and always closes in one click, matching storage.spec.js's single-click "restore failure" test and settings-storage.spec.js's "continue after failure" test. Added a formula-based clickSettingsDone/clickSettingsCancel helper to main-menu.js mirroring SettingsScreen::layout()'s panel/footer math, so the click position tracks the panel across any viewport instead of a hardcoded pixel. Verified against real screenshots at 1200x900, 900x650 and 1100x700. Also fixed a test-design bug this surfaced: the redesigned screen auto- saves on every change, so settings-storage.spec.js's fault-injection tests were injecting the storage fault *after* the dirty click, letting the write land before the fault ever applied. Moved fault injection before the change in both affected tests. Added pixels.js:hasDarkText for the redesigned screen's dark-text-on-paper footer status line (the existing hasLightText assumed the old dark-panel/ light-text styling). Verified locally with the full Emscripten/Playwright toolchain: - settings-storage.spec.js: 5/5 pass - shutdown-storage.spec.js, storage.spec.js's restore-failure test, viewport.spec.js's Settings-touching tests: all pass - Native release build (scons release=1) and web build (scons target=web release=1) both clean. CustomGameScreen (master's other redesign, #237) has the same class of stale-coordinate problem in single-player.spec.js, storage.spec.js and rendering.spec.js; that's unstarted and tracked separately. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV
Continuing the same investigation as the Settings fix: the CustomGameScreen tests failed for the same reason (stale coordinates from before master's lobby redesign, #237), but digging into the one test I couldn't fix with a coordinate swap turned up a real crash bug and a real launch bug, both now fixed and covered by tests. Bug 1 - AI profile picker crashes the browser tab: CustomGameScreen::showAIProfile() (wired to each colony's "Info" button) opened its picker via the old choose()/Screen::execute() blocking-loop pattern. The browser host has no Asyncify, so ApplicationHost::wait() inside that loop is a hard error there (docs/browser/adr-003-screen- execution.md) - clicking Info threw an uncaught exception that unwound past the scheduled-stack driver, freezing the page on that screen with no further input reaching it. #237 added this button without a browser build to test it against; #203 is the first PR that makes CustomGameScreen reachable in one. Fixed by pushing CustomGameChoiceScreen through the ScreenStack CustomGameScreen already holds, same as every other dialog this screen opens, instead of blocking-executing it. choose() is now unused and removed. Verified with page-error listeners: no crash, correct CustomGameScreen <-> CustomGameChoiceScreen transitions, and the picked AI now visibly sticks (confirmed via screenshot: colony row updates from "Numbi - Easy" to "Warrush - Medium" after Use). Bug 2 - launching a randomly generated map fails to load: Engine::initCustomTask(MapHeader, GameHeader, ...) never forwarded a source file path to initGameTask, so GameGUI::loadFromHeaders fell back to deriving one from the map's display name. For a premade map that coincidentally matches a real file (maps/FourSquares1.map), so it worked by accident; for a generated map ("Random map" -> maps/Random_map.map, which never exists) it always failed. Fixed by threading CustomGameScreen::sourceFile() (already tracked, just never passed through) through initCustom/initCustomTask into initGameTask's sourceFileName, in both SinglePlayerFlow::custom() and the harness driver. This also makes premade-map loading correctly use the actual selected file instead of a name-based library guess, rather than working by luck. Verified locally: - CustomGameSetupHarness's ui-mode driver (SDL-event-driven, exercises the Info button and a random-map launch end to end) now passes for all three controller modes; previously failed identically with or without the showAIProfile fix, confirming it's Bug 2, not a regression from Bug 1's fix. All of the harness's other CI-invoked modes still pass. - Native release build, server build, and the web build all clean. - Full local Playwright run across every previously-failing CustomGameScreen test (single-player, storage, rendering, viewport, import, input, replay-save, session-reload): 35/35 pass. Added CustomGameScreen::start()'s footer button as clickCustomGameStart() in main-menu.js (mirrors renderLobby()'s button rect, verified against a screenshot) and used it everywhere a test just needs to launch the preselected default map, replacing the old two-click stale-coordinate sequences. Rewrote single-player.spec.js's AI/rules test around what the redesigned lobby actually exposes: "other options" are now inline Game Rules rows (no separate screen to navigate to and back from), and the AI picker is exercised for real now that it doesn't crash - this doubles as the regression test for Bug 1. import.spec.js's "imports a custom map ... through the normal setup screen" test still needs attention separately: the redesigned CustomGameScreen has no import affordance at all anymore (that lives only in ChooseMapScreen's "Load" flow), so the test's premise - importing directly into the lobby - has no current equivalent to test. Left unstarted pending a decision on whether that's a feature gap to close or a flow the test should follow into ChooseMapScreen instead. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV
…bby button CustomGameScreen has no import affordance anymore after #237's redesign (map browsing there is now "Premade maps"/"Your maps" library tabs, not a file picker) - only ChooseMapScreen still has one, and that screen only offers map-type import when the editor's "Load Map" opens it. Rather than add a new lobby affordance or drop coverage, route the test through the path that actually exists: import via the editor's map chooser, back out without loading it into the editor, then pick it up from the custom-game lobby's own "Your maps" library and start a match with it. Verified the full round trip locally, including that the newly-imported file shows up correctly in the lobby's map preview before starting. Full local Playwright run across import/input/replay-save/session-reload (11 tests, the remaining files touching CustomGameScreen): 11/11 pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XyKsqjkUFwsmf7dxp9SYdV
Glob2Screen and Glob2TabScreen only enabled the front-end theme inside execute(), the old blocking loop. Screens pushed onto a ScreenStack never call it, so menus without their own paint(), such as CampaignMainMenu and CampaignSelectorScreen, fell back to the pre-theme grass background, and generic Text widgets drew legacy white text on the theme's light panels. MainMenuScreen and CustomGameScreen were unaffected only because they paint the theme themselves. Both classes now hold a FrontendScope for the screen's lifetime. That exposed a second problem: FrontendScope restored a snapshot of the previous style, which assumes scopes end in reverse order. ScreenStack::boundary() runs a finished screen's completion callback, which builds the next screen, before destroying the finished one. After any screen handoff the last scope left Style::style pointing at the FrontendTheme, which ~GlobalContainer then deleted after Application had already freed it, so quitting never reached the exited state. Live scopes are now tracked and the most recently created one decides the style and font colours; with none left, the style the theme was created over is restored. This also covers the existing member scopes on GameSessionScreen, MapEditorScreen, CampaignEditor and EndGameScreen. Verified in the WebAssembly build: Campaign and "Choose a campaign" render with the theme panel and dark text, and Credits -> Escape -> Quit reaches exited. Chromium: shutdown-storage (3/3 failing with the member scope alone), settings-storage, campaign-editor-storage and input specs pass. Native builds were not run locally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kem7kazWRvTVQXKfmvX5Tn
Every first load was letterboxed until the window was resized. fitCanvas() had already given the 800x600 placeholder canvas a 4:3 CSS size, and Emscripten's SDL2 adopts a canvas's CSS size when it creates a resizable window, so a 1280x554 viewport got a 738x554 canvas while the game surface stayed 1280x554. The first frame's resizeViewport() found the surface already at the requested size and did nothing; only a later resize called SDL_SetWindowSize. Set the canvas to the viewport size before callMain so SDL adopts the right size. The software renderer now starts at 1280x554; WebGL2 at devicePixelRatio 2 starts with a 2560x1108 backing store shown at 1280x554. The existing initial-viewport test missed this because -s raises sizes below 640x480, which forces a real resize on the first frame. The new test loads at 1280x554 with no resize: it fails on the previous build (width 738) and passes now. viewport.spec.js passes 7/7 in Chromium with both the software and WebGL2 renderers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kem7kazWRvTVQXKfmvX5Tn
The torus overview was compiled out of every Emscripten build. With ?renderer=webgl2 it now works in the browser; the software renderer keeps the flat map, as native software builds do. WebGL and Emscripten's legacy-GL bridge differ from desktop GL in three ways the renderer depended on: - There are no attribute stacks. WebGL builds save and restore exactly the state each torus block changes, and disable client arrays afterwards as AlphaMapRender does, so libgag's GL state cache still matches when the HUD draws. - The bridge draws emulated client-array elements as GL_UNSIGNED_SHORT whatever type is requested. 32-bit indices rendered half the mesh as slivers fanning to vertex 0; WebGL builds use 16-bit indices, which the 161x161 vertex mesh fits. - GLSL ES has no #version 120 line; the bridge rewrites the remaining fixed-function shader inputs. The shell now requests a depth buffer for the WebGL2 context, which the ring needs; the 2D renderer never depth-tests. restoreBrowserContext() bumps the GL context generation, so the overview recreates its textures, framebuffer, buffers and shader after WebGL context loss instead of reusing dead names. glob2Diagnostics gains a torus field for tests. Native builds keep the attribute-stack path; the changed torus sources pass a native macOS syntax check but were not built or run natively. Verified in headless Chromium: torus.spec.js 3/3 (toggle in and out, context loss, software stays flat); rendering and viewport specs 11/11 with WebGL2; input, shutdown-storage, settings-storage and viewport specs 17/17 with software rendering. With an Apple M3 GPU through Metal the overview runs at 24.4 fps against 25 fps flat; SwiftShader drops to 3.4 fps, and the simulation slows with it since it advances per frame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kem7kazWRvTVQXKfmvX5Tn
On this branch the ScreenStack destroys CustomGameScreen before the GameLoadScreen it queued reads the map. The screen's destructor removed its generated preview, so every random-map launch failed with "The map couldn't be loaded because the file is either damaged or an incompatible version." Premade maps were unaffected. Master's blocking flow keeps the screen alive through the load, so it never hit this. CustomGameScreen::releaseSnapshot() hands the preview's private directory to the launch, and SinglePlayerFlow keeps it until the loader's stack entry is released, whether the load succeeded, failed or was cancelled. The directory is still removed before the match runs, as on master. CustomGameSetupHarness launched its random map inside the completion callback, while the screen was still alive, so it missed this. It now loads after the stack destroys the screen and checks that the directory is removed. The changed sources pass a native macOS syntax check; the harness was not built or run locally. Verified in headless Chromium with software rendering: a new single-player test that launches a random map fails on the previous build with that message and passes with this change; single-player.spec.js 15/15. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kem7kazWRvTVQXKfmvX5Tn
DrawableSurface's scaled drawSurface overload was an empty TODO, so software rendering dropped every stretched draw. The custom game lobby stretches its 128x128 map thumbnail to fill the preview square, so browser players on the software renderer saw a flat panel with colony markers and no map. WebGL2 and desktop OpenGL draw through GraphicContext's GPU path and were unaffected. The overload now blits with SDL_BlitScaled: nearest-neighbour like the GPU path, clipped to the surface's clip rect, with per-pixel alpha modulated by the requested alpha. Same-size draws keep using the existing unscaled blit. The other software caller is GUIBase's blocking execute(), which now draws its captured background each frame; the stretched magic effect remains GPU-only through canDrawStretchedSprite(). Verified in headless Chromium: a new single-player test that requires the preview square to be mostly map pixels passes with software and WebGL2 rendering and fails on the previous build (0.2% map pixels); single-player.spec.js 16/16 with software rendering. The changed file passes a native macOS syntax check; no native software-rendering run was done. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kem7kazWRvTVQXKfmvX5Tn
The browser host now selects WebGL2 unless the URL asks for software or the browser can't accelerate it. It probes a scratch canvas, because a canvas that already holds a WebGL context can't fall back to 2D. A context the browser would create with a major performance caveat counts as unavailable, and so does one drawn by a CPU rasterizer: Chrome doesn't report SwiftShader as a caveat, so the renderer name is checked for SwiftShader, llvmpipe, softpipe, Software Rasterizer and Basic Render Driver. ?renderer=webgl2 still forces WebGL2 and ?renderer=software forces software. Measured on an Apple M3 in headless Chrome 152 with a FourSquares1 custom match (10 s, 3 trials; frames are paced to 25 fps): both renderers held 25 fps from 1280x720 to 3840x2160. WebGL2 spent 3.6-12.0 ms of main-thread time per frame against 5.5-23.8 ms for software, and its menus stayed near 25% CPU where software reached 84% at 3840x2160. Total CPU in matches was 3-11 points higher with WebGL2 (37% against 30% at 1920x1080), as the legacy GL emulation works in Chrome's GPU process. With SwiftShader, WebGL2 took three to four cores and fell to 16 fps at 1920x1080 while software held 25 fps under half a core, which is why emulated WebGL2 falls back. Players with an accelerated browser now get WebGL2's presentation by default, including the map zoom controls and the torus overview. Verified: rendering, viewport and torus specs 17/17 in Chromium, including new tests for the accelerated default and the unavailable and emulated fallbacks; runtime-build and viewport specs 18/18 in Firefox and WebKit, which both chose WebGL2 on macOS. In real browsers without a renderer parameter, Chrome with the M3 GPU chose WebGL2 and Chrome with SwiftShader chose software; both URL overrides were honoured. Headless test browsers that emulate WebGL2 keep using software unless GLOB2_TEST_RENDERER forces a renderer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kem7kazWRvTVQXKfmvX5Tn
Browser follow-ups: torus overview, random maps, software map preview, WebGL2 by defaultFour commits after the presentation fixes, deployed to the test bucket as Torus overview in WebGL2 ( Random maps launch again ( Software map preview ( WebGL2 is the default when the browser accelerates it ( Feel and performance impact, for maintainer reviewMost players now get WebGL2 presentation, including map zoom and the torus overview. Local measurement: Apple M3, headless Chrome 152 with Metal, FourSquares1 custom match, 10 s samples, 3 trials, median shown. Frames are paced to 25 fps, so CPU is the comparison.
Both renderers held 25 fps and 25 ticks/s at every size. WebGL2 roughly halves main-thread time and keeps menus cheap, but matches cost 3–11 points of one core more in total, because the legacy GL emulation does its work in Chrome's GPU process. With SwiftShader instead of a GPU, WebGL2 needed 3–4.3 cores and fell to 16 fps at 1920×1080 while software held 25 fps at under half a core, which is why emulated WebGL2 falls back. CPU is summed across Chrome processes from CDP VerificationHeadless Chromium unless noted:
Not verified: none of these changes were built or run natively (the changed C++ for the last two commits passes a macOS syntax check, and CI runs 🤖 Generated with Claude Code |
|
This is even playable in the browser somewhat. Main obstacles are half the screen being an always-on menu and the missing right mouse button. |
Globulation 2 can now run as a full-page WebAssembly browser client while sharing the existing game, UI, save, map, replay, and YOG logic with native builds. The browser supports single-player play, durable local persistence and import/export, an opt-in WebGL2 renderer, and browser/native YOG cross-play through a WebSocket gateway.
This branch is reconstructed from the working browser implementation as five reviewable commits on current
origin/master. The rewrite removes development diaries, handoff notes, screenshots, machine-specific benchmark output, stale migration commentary, and historical checklists. It preserves the existing implementation and formats while fixing current-master integration issues and adding focused safety coverage.Commit series
Each commit completes a native release build independently.
Behavior and compatibility
?renderer=webgl2selects the WebGL2 path, including context-loss restoration.wss=0produces a TCP-only client without OpenSSL or Boost.Beast and fails closed for WSS addresses. LAN is compiled out of WebAssembly because browsers cannot provide its direct TCP listener and discovery model.Presentation fixes
ad1da55). Menus that run through the screen stack without their ownpaint(), such as Campaign and "Choose a campaign", now draw the front-end theme panel and text colours, as they do on master, instead of the legacy grass background. This is a visible change for those menus.FrontendScopenow lets the most recently created live scope decide the style: a screen stack builds the next screen before destroying the finished one, and restoring style snapshots in that order leftStyle::stylepointing at the destroyed theme during shutdown.62365ce). The shell sizes the canvas to the viewport before SDL creates its window. SDL previously adopted the 4:3 CSS fit of the 800×600 placeholder canvas, so every first load was letterboxed until the window was resized.Validation for these two commits (WebAssembly release build, Chromium):
viewport.spec.jspasses 7/7 with both the software and WebGL2 renderers. Its new first-load test fails on the previous build (canvas width 738 instead of 1280).shutdown-storage,settings-storage,campaign-editor-storageandinputspecs pass.shutdown-storagecaught the snapshot-order bug: it failed 3/3 with the theme scope alone and passes on the original branch build.Build and run
Open
http://127.0.0.1:8765/for software rendering or add?renderer=webgl2. Seebrowser/README.mdfor the pinned toolchain, tests, gateway setup, and troubleshooting.A hosted single-player build is available at Launch Globulation 2. Multiplayer gateway hosting and a project-website Play button are not included in that demo. A later browser multiplayer experience can build shareable match links, lightweight guest identity, and instant matchmaking on top of this PR’s YOG cross-play foundation.
Validation
Local validation on the rewritten branch includes:
PR CI keeps the expensive coverage bounded: one WebAssembly build, the complete Chromium behavior suite, focused Firefox/WebKit compatibility checks, focused Chromium WebGL2 and visibility coverage, and a 60-minute cap on the browser job. Native builds remain in the repository’s existing compiler lanes instead of being repeated in the browser job. Browser executions were reduced from 359 to 110, the prior build-order matrix and automatic daily run were removed, and large development artifacts upload only for manual runs with seven-day retention.
The final four-job CI run for the rewritten SHA is the merge gate.